Pythonプロジェクトに静的解析ツールのRust製Ruffを導入してflake8/black/isortを置き換えてみた

Pythonプロジェクトに静的解析ツールのRust製Ruffを導入してflake8/black/isortを置き換えてみた

Clock Icon2024.08.08

Pythonプロジェクトでflake8/black/isortといった静的解析プログラムと設定ファイルが取っ散らかっていませんか?

Ruffを利用すると、設定ファイルをpyproject.tomlに集約し、次の3コマンドを叩くだけで、リンターを走らせ、コード整形できます。

$ curl -LsSf https://astral.sh/ruff/install.sh | sh
$ ruff check --fix
$ ruff format

Python用静的解析ツール Ruff 導入の背景

最近関わるようになったプロジェクトでは、アプリケーションにPython、IaCにTypeScriptが利用されています。

IaC(AWS CDK)のTypeScriptはリンターやコード整形といった静的解析が自動テストの一環で行われているのに対して、頻繁に更新するアプリケーションのPythonは、各自が手元で各コマンドを個別に実行することを期待する運用になっており、コードレビュー時にレビュアーが静的解析の実行を指摘している状況でした。

Python側にも静的解析の自動テストを導入して、レビュー開始時にTypeScriptと同程度の品質を担保したいよねという話になり、そこで現れたのが Ruff です。

静的解析の人気プログラム(flake8/black/isort)、及び、設定ファイル(pyproject.toml)と互換性があり、1プログラムに集約でき、Rust製で高速に動作し、GitHub ActionsやVSCodeなどと連携機能が容易であり、FastAPI、Pandas、Apache Superset等の人気OSSでの導入実績も豊富なことから、新興のRuffを導入することにしました。

Pythonの静的解析に求められること

Pythonの静的解析は、リンター(flake8)、コード整形(black)、インポート整形(isort)など機能ごとにツールが乱立していましたが、現在は新興のRuffに集約できます。

Pythonの静的解析に求められることを確認します。

リンター

リンターを利用すると、不要なインポートや定義されていない変数を検知できます。

import os   # 不要なインポート
print(n)    # 定義されていない変数

リンターとして従来は Flake8 などが有名でした。

$ flake8 sample_linter.py
sample_linter.py:1:1: F401 'os' imported but unused
sample_linter.py:2:7: F821 undefined name 'n'

現在は ruff check で置き換えられます。

$ ruff check sample_linter.py # 検出
sample_linter.py:1:8: F401 [*] `os` imported but unused
  |
1 | import os
  |        ^^ F401
2 | print(n)
  |
  = help: Remove unused import: `os`

sample_linter.py:2:7: F821 Undefined name `n`
  |
1 | import os
2 | print(n)
  |       ^ F821
  |

Found 2 errors.
[*] 1 fixable with the `--fix` option.

$ ruff check --fix sample_linter.py # `--fix` で修正
sample_linter.py:1:7: F821 Undefined name `n`
  |
1 | print(n)
  |       ^ F821
  |

Found 2 errors (1 fixed, 1 remaining).

$ cat sample_linter.py
print(n)

コード整形

複数人で開発するときは、変数の定義、関数の引数、if文の書き方といったコーディング規約が統一されていると、保守しやすいです。そこで登場するのがコード整形です。

整形前

if n:print("ok")

整形後

if n:
    print("ok")

コード整形として従来は Black などが有名でした

$ black sample_format.py
reformatted sample_format.py

All done! ✨ 🍰 ✨
1 file reformatted.

現在は ruff format で置き換えられます。

$ ruff format --check sample_format.py # コード整形の必要製の検出
Would reformat: sample_format.py
1 file would be reformatted

$ ruff format sample_format.py         # コード整形
1 file reformatted

インポート整形

インポート文をアルファベット順にソートしたり、標準・非標準で分けたいことがあります。

整形前

import requests
import sys
import os

整形後

import os
import sys

import requests

インポート文整形として従来は isort などが有名でした

$ isort sample_import.py
Fixing /Users/user/sample_import.py

現在は ruff check --extemd-select I で置き換えられます。

$ ruff check --extemd-select I --fix sample_import.py
Found 1 error (1 fixed, 0 remaining).

インポート文の整形はRuffリンターの一機能

isort 相当の機能について、Ruff のコード整形(ruff format)はインポート文をソートしないため、リンター機能(ruff check)で対応します。

Currently, the Ruff formatter does not sort imports. In order to both sort imports and format, call the Ruff linter and then the formatter:

ruff check --select I --fix
ruff format

A unified command for both linting and formatting is planned.

https://docs.astral.sh/ruff/formatter/#sorting-imports

統合が予定されているとはいえ、多くの人がほしいのは、不要なインポートは削除し、必要なインポートをソートするようなコード整形ではないかと思います。

既存のリンター機能(ruff check)にデフォルトでは無効な isort 機能を有効化することで(--extend-select)、インポート文の整理とソートができます。

参考

Git フック連携

Gitのコミット時にフック機能を利用して、静的解析を走らせる方法を紹介します。

まず、pre-commit をインストールします。

$ brew install pre-commit

次にレポジトリのトップディレクトリに、次の .pre-commit-config.yaml ファイルを用意します。

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.5.6
    hooks:
      - id: ruff
        args: [--extend-select, I, --fix]
      - id: ruff-format

最後に、フックスクリプトをデプロイします。

$ pre-commit install
pre-commit installed at .git/hooks/pre-commit

コミット時に自動的にRuffの静的解析が走るようになります。

$ git commit -m test
[WARNING] Unstaged files detected.
[INFO] Stashing unstaged files to /Users/user/.cache/pre-commit/patch1723031830-70446.
ruff.....................................................................Failed
- hook id: ruff
- exit code: 1
- files were modified by this hook

a.py:1:7: F821 Undefined name `n`
  |
1 | print(n)
  |       ^ F821
  |

Found 2 errors (1 fixed, 1 remaining).

ruff-format..............................................................Passed
[INFO] Restored changes from /Users/user/.cache/pre-commit/patch1723031830-70446.

参考

VSCode連携

VSCodeの保存時にRuffの静的解析を走らせる方法を紹介します。

Ruffの開発元のAstral Softwareが提供しているVSCode Extentionをインストールします。

vscode-ruff-ext

ニアリアルタイムにRuffが実行されます

vscode-ruff

特に、 .vscode/settings.json に次の設定を追加すると、保存時に Ruff を走らせることができます。

{
  "[python]": {
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "charliermarsh.ruff",
    "editor.codeActionsOnSave": {
      "source.fixAll": "explicit",
      "source.organizeImports": "explicit"
    }
  }
}

参考

GitHub Action連携

RuffはGitHub Actionとの連携もスムーズです。

GitHub Actionで細かく制御した場合は、Ruffを直接インストールし、オプションを細かく指定すると良いでしょう。

name: Linter
on: [ push, pull_request ]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"
      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install ruff
      # Update output format to enable automatic inline annotations.
      - name: Run Ruff
        run: ruff check --output-format=github
      - name: Run Ruff
        run: ruff format --check

凝った設定が不要な場合は、 ruff-action に寄せるといいでしょう。

name: Linter
on: [ push, pull_request ]
jobs:
  ruff:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: chartboost/ruff-action@v1
        with:
          args: check --output-format=github
      - uses: chartboost/ruff-action@v1
        with:
          args: format --check

pyproject.toml設定例

Ruff の大きなメリットの一つは、各機能の設定を pyproject.toml に集約できることです。

もともとデフォルト値で Black 等を走らせていたことや、パラメーターに凝りだすと運用負荷が増すため、ほぼデフォルト値のままとしています。

[tool.ruff]
target-version = "py310"

[tool.ruff.lint]
ignore = [
  "E501", # Line too long
]

select = [
  "E",   # pycodestyle
  "F",   # pyflakes
  "I",   # isort
]

デフォルトとの目立った違いについて触れます。

isort の有効化

isort のインポート文整形をしたかったため、selectI(isort) を追加しています。

行の長さをどうするか?

既存コードベースの都合から、長い行に対するエラー(E501)を除外しています。

以下のようにして、コード整形(line-length)とE501(max-line-length)に異なる長さを指定することもできます。

[tool.ruff]
# The formatter wraps lines at a length of 88.
line-length = 88

[tool.ruff.lint.pycodestyle]
# E501 reports lines that exceed the length of 200.
max-line-length = 200

長い1行のままにしたほうが見通しが良い場合は、 # noqa: E501 のコメントを追加して除外することも可能です。

また、$ ruff format のあと $ ruff check を走らせると、日本語のようなマルチバイトを含んだ行が E501 で引っかかるケースに遭遇します。文字数ではなくUnicode widthの幅を利用しているのが原因のようです。

参考

特定行を静的解析対象から外す

Ruff は Flake8 と同様に、行の末尾に # noqa あるいは # noqa: {code} のコメントを追記すると、警告を抑制できます

x = 1  # noqa

"""Lorem ipsum dolor sit amet.

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor.
"""  # noqa: E501

Ruff 実行時のステータスコード

Ruff の実行結果によるステータスコードの違いを確認します。

check 時

リンターの ruff check 実行時は、異常を検知すると、異常系の非0のステータスコードを返します。

$ ruff check a.py
a.py:1:7: F821 Undefined name `n`
  |
1 | print(n)
  |       ^ F821
  |

Found 1 error.

$ echo $?
1

利用されていないインポート文の削除のような修正(--fix)をした時は、正常ステータスの 0 を返します。

$ ruff check --fix fix.py
Found 1 error (1 fixed, 0 remaining).
$ echo $?
0

このような修正のケースで異常系の1を返したい時は --exit-non-zero-on-fix オプションを追加します。

$ ruff check --fix  --exit-non-zero-on-fix fix.py
Found 1 error (1 fixed, 0 remaining).
$ echo $?
1

format 時

コード整形のruff format実行時は、正常ステータスの 0 を返します。

$ ruff format b.py
1 file reformatted

$ echo $?
0

コード整形(の候補検出)を異常とみなしたい場合は、--check オプションを追加して下さい。

$ ruff format --check b.py
Would reformat: b.py
1 file would be reformatted

$ echo $?
1

まとめ

Pythonの静的解析をRuffに集約させ、GitHub Action/VSCode/pre-commit等と連携する方法を紹介しました。

isort 相当のソート文をソートする機能の実現と 整形・E501と関連する line-length のニュアンスに少しハマりましたが、それ以外については、各種互換性や連携機能のおかげで、非常にスムーズに導入できました。

参考

この記事をシェアする

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.